E-이스티오에서 엔보이 기능 확장하기

개요

이미 이스티오에서는 엔보이를 활용해 굉장히 많은 트래픽 제어 기능을 제공한다.
그러나 사실 순정 엔보이는 이것보다 더한 능력을 가진 놈이고, 다양한 방법으로 설정을 확장시킬 수 있다..!
기본 이스티오 설정만으로 충분하지 않은 조직을 위해, 이스티오에서는 엔보이를 설정을 확장시킬 수 있는 다양한 방법을 추가적으로 제공하고 있다.
염두할 것은 이스티오는 그저 엔보이에 다양한 설정을 적용할 수 있는 창구만 마련해주는 것이고, 엄연히 이건 커스텀 영역에 들어간다.
즉, 여기에서 잘못 만졌다가 메시가 망가지거나 하는 것은 전적으로 사용자 부담입니다 ㅋ..

어떤 기능들을 확장할 수 있을지 예시를 들어보자.

커스텀해야 한다는 게 상당한 부담이더라도, 이러한 운영 전략이 필요한 조직에서는 충분히 시도해볼만한 것들이라 할 수 있겠다.

설정 확장 방법

근데 엔보이는 도대체 어떻게 저런 다양한 기능을 때려박을 수 있는 것일까?
엔보이의 컴포넌트 구조를 보면 조금 더 구체적으로 파악할 수 있다.

엔보이는 기본적으로 리스너에서 트래픽을 받고, 여기에 여러 필터를 때려박은 뒤에 업스트림 클러스터로 전달하는 역할을 수행한다.
image.png
이때 이 리스너 필터를 체인으로 구성할 수 있는데 엔보이에서는 여기에 기본적으로 매우 다양한 필터를 제공한다.[1]
image.png
이뿐이랴?
네트워크 필터 중에 HTTP 트래픽을 설정하는 HTTP Connection Manager라는 필터에 또 http 관련 수많은 필터를 때려박을 수 있다..

다시 말해, 엔보이의 기능을 확장한다는 것은 이러한 필터들을 넣고 설정하는 것을 말하는 것과 같다.
이스티오에서는 몇 가지 필터들을 기본 기능으로 흡수하여 사용자가 이스티오 네이티브하게 적용할 수 있게 제공한다.
대표적인 것이 에러 주입, 인가 정책 등이다.

다른 필터들을 사용하고 싶으면 이제 진짜 커스텀이 시작되는 것이다.
이스티오에서 필터를 커스텀할 수 있도록 제공하는 리소스는 다음과 같다.

와즘 플러그인만 봐도 알겠지만, 엔보이에서 기본으로 제공하는 필터 중에는 사용자가 새로운 로직을 넣을 수 있는 인터페이스 역할을 하는 필터도 존재한다.
위 항목에 존재하지 않는 독자적인 기능을 가진 필터를 넣고 싶다! 싶을 때 직접 C++로 코드를 빌드해서 넣는 것도 가능하지만,(...)
인터페이스 역할을 하는 필터에 여러 로직을 넣는 것도 가능하다.
이때 사용되는 방법이 크게 두 가지이다.
하나는 위의 와즘 플러그인 리소스이고, 두번째는 엔보이필터 리소스에 루아(Lua) 스크립트 필터를 사용하는 것이다.

그래서 이번 주차에서는 총 3가지 방식으로 기능 확장을 해볼 것이다.

실습 간 엔보이 필터 생겨먹은 방식은 정말 몰라보겠다 싶으면 이스티오 엔보이필터 참고.

엔보이 기본 필터 - tap

기본 세팅은 여태 한 것과 다르지 않아 그냥 레포 참고 바란다.
이미 존재하는 것을 다시 새로 만들 필요는 당연히 없을 것이다.
그래서 먼저 기본으로 존재하는 필터를 엔보이 필터로 적용해보는 것으로 시작한다.

트래픽을 실시간으로 내 화면에서 디버깅하고 싶다고 해보자.
이 요구사항을 이스티오의 기능으로 충족할 방법은 없다.
트레이싱도 결국 트레이스가 끝나고 데이터를 취합한 이후에 확인하는 로그에 불과하다.
하지만 엔보이에는 tap 필터가 존재한다.[2]
tap 필터는 특정 조건에 따라 자신을 지나가는 트래픽을 어떤 수신 에이전트로 스트리밍해줄 수 있다.
조금 더 구체적으로는 엔보이 관리자 포트로 /tap 경로로 POST 요청을 받으면 관리자를 위한 스트리밍을 시작한다.

name: envoy.filters.http.tap
typed_config:
  "@type": type.googleapis.com/envoy.extensions.filters.http.tap.v3.Tap
  common_config:
    admin_config:
      config_id: test_config_id
      tap_config:
        match_config:
          and_match:
            rules:
              - http_request_headers_match:
                  headers:
                    - name: foo
                      exact_match: bar
              - http_response_headers_match:
                  headers:
                    - name: bar
                      exact_match: baz
        output_config:
          sinks:
            - streaming_admin: {}

설정은 이런 식으로 하는데, 현재 설정은 관리자가 탭으로 들어가면 관리자의 요청 경로로 각종 트래픽을 스트리밍하기 시작한다.
위의 설정에서는 헤더에 foo: bar가 들어가는 요청, 그리고 헤더에 bar: baz가 들어가는 응답이 있다면 해당 내용을 관리자에게 바로 송출해줄 것이다.
그럼 탭 필터를 사용해보자.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: tap-filter
  namespace: istioinaction
spec:
  workloadSelector:
    labels:
      app: webapp
  configPatches:
  - applyTo: HTTP_FILTER # 설정할 위치
    match:
      context: SIDECAR_INBOUND
      listener:
        portNumber: 8080
        filterChain:
          filter:
            name: "envoy.filters.network.http_connection_manager"
            subFilter:
              name: "envoy.filters.http.router"
    patch: # 엔보이 설정 패치
      operation: INSERT_BEFORE
      value:
       name: envoy.filters.http.tap
       typed_config:
          "@type": "type.googleapis.com/envoy.extensions.filters.http.tap.v3.Tap"
          commonConfig:
            adminConfig:
              configId: tap_config

일단 이 엔보이 필터가 적용될 범위를 istioinaction의 특정 라벨을 가진 워크로드로 제한했다.
적용할 설정을 리스트로 작성하는데, 먼저 applyTo를 이용해 어디에 적용할지 지정하고, match를 통해 세부적인 위치를 잡는다.
그리고 patch로 어떤 설정을 넣을지 지정한다.
매칭된 필터를 기준으로 어디에 넣을지 operation을 지정한다.

위 설정은 hcm 필터의 가장 마지막인 라우터 필터를 먼저 매칭하고, 바로 앞에 탭 필터를 넣는 리소스인 것이다.

kaf ch14/tap-envoy-filter.yaml

image.png
보다시피 엔보이 필터를 적용하면 일단 조심하라는 말이 나온다.

이 설정은 엔보이의 리스너 설정에서 확인할 수 있다.

istioctl proxy-config listener deploy/webapp.istioinaction --port 15006 -o json

image.png
15006, 즉 인바운드 핸들러 부분을 보면 먼저 여러 필터 체인이 걸려 있는 것을 확인할 수 있다.
앞단의 필터 체인들은 매칭이 안 되거나, 비정상 요청을 처리할 때 쓰이는 체인들이고 8080 포트에 매칭되는 필터 체인에서 탭필터가 적용된 것을 찾을 수 있다!
위에서 봤듯이 일단 리스너의 http 필터인데 포트가 8080이고 라우터 필터까지 매칭이 된 것이다.
image.png
보다시피 INSERT_BEFORE가 적용되어 라우터 필터 앞단에 위치하고 있다.

확인

이제 tap 경로로 실시간 디버깅을 구경해보자!

kubectl port-forward -n istioinaction deploy/webapp 15000 &
curl -X POST -d @./ch14/tap-config.json localhost:15000/tap

webapp 이미지에 curl이 없기 때문에 포트포워딩해서 사용한다.
참고로 @은 curl에서 파일의 값을 인자로 사용할 때 사용하는 특수 문자이다.
image.png
해당 파일에는 엔보이 필터를 적용할 때 넣어놨던 config_id로 탭 설정을 어떻게 할지 명시돼있다.
보다시피 요청 헤더에 x-app-tap: true면 해당 정보가 그대로 스트리밍될 것이다.
image.png
이렇게 커넥션이 연결된 채로 무한 대기가 걸리면 성공이다.
이제 다른 터미널에서 실험을 진행하면 된다.

추가적으로 엔보이에서 로그를 조금 더 세밀하게 보고자 레벨을 설정해줬다.

istioctl proxy-config log deploy/webapp -n istioinaction --level http:debug
istioctl proxy-config log deploy/webapp -n istioinaction --level tap:debug
k stern -n istioinaction webapp-7c96945758-np7lz

헤더가 있는 요청과 없는 요청을 날려본다.

alias de='docker exec -ti mypc '
EXT_IP=$(kubectl -n istio-system get svc istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
de curl -s -H "Host: webapp.istioinaction.io" http://$EXT_IP/api/catalog
de curl -s -H "x-app-tap: true" -H "Host: webapp.istioinaction.io" http://$EXT_IP/api/catalog

image.png
인그레스 게이트웨이를 타고 들어온 요청에 tap 관련 헤더가 달렸다.
image.png
tap을 열어둔 콘솔에서 어무막지한 길이의 정보가 들어온 것을 확인할 수 있다!
길이가 길어서 생각보다 읽기는 어려운데, 잘 확인해보면 요청 트래픽과 응답 트래픽이 둘 다 출력되는 것을 확인할 수 있다.
image.png
응답 트래픽에는 body가 담겨있는데, 이건 당연히..
image.png
기본 요청했을 때 받아보게 되는 데이터이다.

엔보이 기본 필터 - rate limitting

엔보이의 기본 필터에는 무려 요청 개수 제한을 적용하는 rate limitting 필터도 있다![3]
image.png
엔보이에는 다양한 속도 제한 방법이 있는데,[4] rate limit 필터로 글로벌 제한을 걸어보자.
참고로 글로벌 방식은 매칭되는 엔보이들이 속도 제한 상태를 공유하는 방식으로, 외부에 속도 제한에 대한 판단과 정보를 가지는 서버(Rate Limitting Server)를 두고, 엔보이가 이 정보를 받아온다.
예제에서는 엔보이에서 자체적으로 제공하는 속도 제한 서버를 사용하는데, 이건 기본적으로 레디스와 통신하며 상태를 관리한다.[5]

이 필터는 요청에서 특정 속성들을 가져와 서버에 평가를 위임한다.
그 다음 서버는 들어오는 요청들의 속성들을 캐싱하며 카운트를 센다.
그러다 임계값을 넘기면 서버는 거부 결정을 내리고, 결과적으로 요청이 거부된다!
제한을 넘기면 429 too many requests를 바로 반환해버려서 실제 어플리케이션에 요청은 도달하지도 않을 것이다.
추가적으로 x-envoy-ratelimited 헤더를 응답에 붙여 이 필터로 제한됐다는 것도 알린다.

예제에서 매우 재밌는 시나리오를 준비해뒀는데, 그건 바로 사용자 그룹을 분리하는 것..
x-loyalty 헤더로 사용자마다 등급을 매겨서 요청 개수에 차등을 두는 것이다!
골드 등급은 분당 10개, 브론즈는 분당 3개를 허용하는 방식..

아무튼 이를 위해 배포해야 하는 것들

image.png
속도 제한 서버는 이런 식으로 레디스와 통신하고 컨피그맵으로부터 설정을 받는다.
엔보이 필터 측 설정은 이렇게 두 가지를 넣어줘야 한다.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: catalog-ratelimit-filter
  namespace: istioinaction
spec:
  workloadSelector:
    labels:
      app: catalog
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: SIDECAR_INBOUND
      listener:
        portNumber: 3000
        filterChain:
          filter:
            name: "envoy.filters.network.http_connection_manager"
            subFilter:
              name: "envoy.filters.http.router"
    patch:
      operation: INSERT_BEFORE
      value:
        name: envoy.filters.http.ratelimit
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit

          domain: catalog-ratelimit
          failure_mode_deny: true
          rate_limit_service:
            grpc_service:
              envoy_grpc:
                cluster_name: outbound|8081||ratelimit.istioinaction.svc.cluster.local
              timeout: 10s
            transport_api_version: V3

---
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: catalog-ratelimit-actions
  namespace: istioinaction
spec:
  workloadSelector:
    labels:
      app: catalog
  configPatches:
    - applyTo: VIRTUAL_HOST
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          vhost:
            route:
              action: ANY
      patch:
        operation: MERGE
        # Applies the rate limit rules.
        value:
          rate_limits: # 속도 제한 조치
            - actions:
              - header_value_match:
                  descriptor_value: no_loyalty
                  expect_match: false
                  headers:
                  - name: "x-loyalty"
            - actions:
              - header_value_match:
                  descriptor_value: bronze_request
                  headers:
                  - name: "x-loyalty"
                    exact_match: bronze
            - actions:
              - header_value_match:
                  descriptor_value: silver_request
                  headers:
                  - name: "x-loyalty"
                    exact_match: silver
            - actions:
              - header_value_match:
                  descriptor_value: gold_request
                  headers:
                  - name: "x-loyalty"
                    exact_match: gold

첫번째는 http 필터로 rate limit 필터를 넣는 설정으로, 3000포트의 http 필터 체인 중 가장 마지막에 넣는다.
(어차피 router 필터가 필수적으로 "진짜" 마지막이기 때문에 마지막이라 표현했다.)
그리고 모든 라우트 설정으로 모든 가상 호스트에 대해 rate_limits라는 필드를 위와 같이 병합한다.

kubectl apply -f ch14/rate-limit/rlsconfig.yaml -n istioinaction
kubectl apply -f ch14/rate-limit/rls.yaml -n istioinaction
kubectl apply -f ch14/rate-limit/catalog-ratelimit.yaml -n istioinaction
kubectl apply -f ch14/rate-limit/catalog-ratelimit-actions.yaml -n istioinaction

예제 양식으로 쉽게 적용할 수 있다.

이제 실제로 적용되는지 테스트해본다.

kubectl exec -it deploy/sleep -n istioinaction -c sleep -- curl http://catalog/items -v
kubectl exec -ti -n istioinaction debug -- curl -H "x-loyalty: silver" http://catalog/items -v

for i in {1..10};
do
  kubectl exec -ti -n istioinaction debug -- curl -s -o /dev/null -w "%{http_code}\n" http://catalog/items
  sleep 0.5
done | sort | uniq -c

image.png
아무런 등급 없는 살람은 한시간에 한번만 요청할 수 있다..
image.png
실버 등급의 사람은 그래도 조금 더 많이 요청이 가능하다!
image.png
골드 등급만이 서비스를 정상적으로 이용할 수 있는 매우 영세적인 서비스이다!

엔보이 필터 - Lua 필터

엔보이에서 자체적으로 기능을 확장할 수 있도록 마음껏 커스텀 스크립트를 작성할 수 있는 필터, 루아 필터도 사용해보자!
루아 스크립트는 임베딩 목적으로 설계된 매우 간단한 경량의 언어로, 사용 방법을 모르거나 조금 알아가고 싶다면 해당 문서 참고.
정말 간단하게 만들어져있어서 간단하게 사용할 수 있도록 익히는 데는 한 시간 정도만 들여도 충분하다.
기본만 알면 지피티가 써줄그니까아

엔보이에서는 루아 필터에 LuaJIT를 이용해 코드를 실행하여 다양한 동작을 할 수 있도록 돕고 있다.[6]
알아야 할 지점이 몇 가지 있다.

루아 필터를 할 수 있는 기능은 다음의 것들이 있다.

구체적으로 루아 필터를 작성할 때는 두 가지 함수를 넣는 식으로 해주면 된다.

          - name: lua-custom-name
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
              default_source_code:
                inline_string: |
                  function envoy_on_request(request_handle)
                    -- Do something.
                  end
                  function envoy_on_response(response_handle)
                    -- Do something.
                  end

요청에서 envoy_on_request() 발동, 응답에서 envoy_on_response().
각 함수를 만들어서 코드를 넣어주면 된다!

해당 라우트를 지난다고 죄다 실행시켜버리는 게 싫다면, LuaPerRoute란 필터를 이용해서 세부 조건에 매칭된 라우트에만 발동시키게 설정하는 것도 가능하다.[7]
아래 예시가 이해하기에는 더 좋은 것 같다.

      - name: envoy.filters.network.http_connection_manager
        typed_config: ""
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/lua-disabled"
                typed_per_filter_config:
                  envoy.filters.http.lua:
                    "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.LuaPerRoute
                    disabled: true
              - match:
                  prefix: "/lua-custom"
                typed_per_filter_config:
                  envoy.filters.http.lua:
                    "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.LuaPerRoute
                    name: hello.lua
              - match:
                  prefix: "/lua-inline"
                typed_per_filter_config:
                  envoy.filters.http.lua:
                    "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.LuaPerRoute
                    source_code:
                      inline_string: |
                        function envoy_on_response(response_handle)
                          response_handle:logInfo("Goodbye.")
                        end
          http_filters:
          - name: envoy.filters.http.lua
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
              default_source_code:
                inline_string: |
                  function envoy_on_request(request_handle)
                    -- do something
                  end
              source_codes:
                hello.lua:
                  inline_string: |
                    function envoy_on_request(request_handle)
                      request_handle:logInfo("Hello World.")
                    end
          - name: envoy.filters.http.router

(너무 길다 싶어 몇 부분을 생략했다.)
http_filters에서 원래 해당 hcm으로 들어온 트래픽에 전체 적용될 루아 필터를 작성할 수 있다.
이때 default_source_code를 통해 기본으로 실행될 것을 지정할 수 있다.
source_codes로 각 라우트 별로 실행할 코드들을 등록시켜두는 것도 가능하다.
라우트 별로 루아를 실행할 때는 내부에 typed_per_config를 쓰고 여기에 LuaPerRoute를 넣어주면 된다.

아무튼 이렇게 설정하면 모든 요청에 대해 루아를 실행시켜서 필터링하는 비효율을 감내할 이유가 없다.

지원 api

문서를 보면서 알아두면 좋을 것 같은 몇 가지 api를 정리해봤다.
자세히 알기 싫다면 빠르게 스크롤을 내려도 좋다.

log

로그를 남긴다.
구체적으로는 아래의 값들이 가능하다.

참고로 이 log 메서드들은 아래의 메서드들을 통해 반환된 오브젝트들도 각각 전부 존재한다.

headers()

말 그대로 헤더 관련 오브젝트를 받아온다.
해당 오브젝트로는 또 아래 메서드들을 수행할 수 있다.

pairs를 이용해서 반복문을 돌릴 수도 있다.

for key, value in pairs(headers) do
end

bodys(), bodyChunks()

바디 오브젝트를 반환한다.
근데 bodys()는 바디를 전부 받아올 때까지 블락한다.
이때 버퍼링의 최대값은 HCM에 의해 설정되니 전부 받아올 수 있을 것이란 기대는 버려야 할 수도 있다.
bodyChunks()는 데이터를 조금씩 받아서 처리할 수 있도록 청크 이터레이터를 반환한다.

for chunk in request_handle:bodyChunks() do
  request_handle:log(0, chunk:length())
end

이렇게 받아온 바디 데이터에는 다음의 메서드를 호출할 수 있다.

connection(), streamInfo()

현재 커넥션, 스트림 오브젝트를 반환한다.
커넥션은 tcp 커넥션을 말한다.
스트림은 http/2의 경우 더 지엽적이고 구체적인 정보를 가질 것이다.

커넥션에는 ssl 메서드가 있다.

ssl = connection:ssl()
if ssl == nil then
  print("plain")
else
  if ssl:peerCertificatePresented() then
    print("peer certificate is presented")
    if ssl:peerCertificateValidated() then
      -- 재개된(resumed) tls 세션에 대해서는 검증 실패한다.
      print("peer certificate is validated")
    end
  end
end

ssl 오브젝트는 이밖에도 다양한 메서드를 지원한다.
tls 통신 관련 문제는 여기에서 전부 설정해주면 될 것이다.

httpCall()

local headers, body = handle:httpCall(cluster, headers, body, timeout_ms, asynchronous)
-- 뒤 인자들은 아래처럼 options로 퉁치기 가능
local headers, body = handle:httpCall(cluster, headers, body, options)

cluster에 요청을 보낼 업스트림 호스트를 넣는다.
이 말은, 엔보이 필터로 다른 곳으로 요청을 보내려면 클러스터 쪽에도 원하는 호스트를 세팅해야만 한다는 뜻이다.
headers에는 :method:path, and :authority 를 필수 세팅해야 한다.

options에는 다음의 값들을 가질 수 있다.

metadata()

라우트 메타데이터 정보를 가져온다.

              metadata:
                filter_metadata:
                  lua-custom-name:
                    foo: bar
                    baz:
                    - bad
                    - baz

엔보이 설정에 이런 거 있을 때 가져올 수 있다는 것이다.

respond()

(이건 request 함수에서만 가능하다.)
필터에서 바로 응답 보내버리기!
근데 다음 필터로 값이 전달되기 전에 수행돼야 한다.
그래서 bodyChunks()랑 같이 못 쓴다!

setUpstreamOverrideHost()

(이건 request 함수에서만 가능하다.)
업스트림을 바꿔치기한다.

function envoy_on_request(request_handle)
  request_handle:setUpstreamOverrideHost("192.168.21.13", false)
end

이 설정을 넣으면 기존에 이뤄져야 할 추후 클러스터 로드밸런싱이 적용되지 않고 바로 해당 주소로 날아간다.
뒤 인자는 false면 해당 주소로의 요청 실패 시 기존의 흐름을 계속 타게 만든다.
true일 경우에는 해당 주소로의 요청이 실패하면 바로 503을 반환시킨다.

verifySignature()

해시 서명을 검증한다.

local ok, error = handle:verifySignature(hashFunction, pubkey, signature, signatureLength, data, dataLength)

data 부분에 검증하고자 하는 데이터, signature에 서명 데이터를 넣으면 된다.
이걸로 TLS 조작이라도 하나 했는데, HCM에 들어가는 시점에 이미 복호화가 완료되므로 그냥 원하는 특정 값을 검증할 때 쓰이는 것으로 보인다.

본격 실습하기

한번 요청 경로의 동작을 루아로 장난질 해보자.
책에서 나오는 시나리오와 예제도 있긴 한데, 썩 맘에 들지 않아서 임의의 시나리오를 짜봤다.

외부에서 요청이 들어오는 상황이다.
이 요청에 대해 A/B 테스트를 해야 한다.
이때 요청의 헤더를 기반으로 그룹을 분리시킬 것이다.
이 헤더는 외부에 유출되어서는 안 되는 관계로, 메시 내에서 세팅하고 처리해야만 한다.
여기까지의 동작은 버츄얼 서비스에서 쉽게 설정할 수 있다.

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: reviews-route
spec:
  hosts:
  - reviews.prod.svc.cluster.local
  http:
  - headers:
      request:
        set:
          test: "true"
    route:
    - destination:
        host: reviews.prod.svc.cluster.local

그런데 수많은 서비스가 존재하는 지라 모든 버츄얼 서비스 리소스를 일일히 설정하는 것은 퍽 번거롭다.
여기에 운영 요구사항이 계속 수정돼서 해당 헤더를 넣고 빼는 일도 잦고, 헤더의 값에 따라 자유롭게 비율을 조절해야 하는 등 비효율적인 업무의 반복이 이어지고 있다.
야근에 시달리는 말단 운영자 Z는 꼭지가 돌아버릴 지경이라 문득 위험한 생각을 떠올렸다.
"모든 엔보이에 대해 헤더를 자유롭게 세팅할 수 있는 중간 서버를 두는 건 어떨까?"
이 친구는 아직 헬름, 쿠스토마이즈를 베우지 못했나보다..
개발팀과의 협업으로 이를 달성할 수 있으면 좋겠지만, 개발팀은 비즈니스 로직을 처리하는데 너무 바빠 직접 이를 처리해야 할 것으로 보인다.

가급적 간단하게 터미널 환경에서도 설정이 가능하게 하고 싶은 Z는 restful하지 않은 api를 설계했다.
기능 목표가 크지 않고 확장성이 요구되지 않는 관계로, 로직만 명확히 정의되어 있다면 문제가 없을 것으로 보인다.

- /get - 헤더를 조회한다.
	- / - 모든 헤더를 조회한다.
	- /{key} - 특정 헤더 키를 조회한다.
- /set - 헤더를 설정한다.
	- /{key}?value={[]string}&weight={[]int} - 헤더에 들어갈 값들을 리스트로 설정하고 가중치를 부여한다.
- /header - 설정된 헤더를 반환한다.
	- set된 모든 헤더 키가 들어가며, 값으로는 가중치 비율에 맞춰 값이 들어간다.

이 정도라면 Z는 매우 간단하게 서버를 구현할 수 있을 것으로 보인다.
이제 엔보이에서 이 서버로 요청을 날리는 과정이 필요한데, Z는 고민하다 엔보이 필터를 사용해보기로 결정했다.
일을 줄이려고 일을 키우는 놈인 듯하다
그리고 엔보이 필터에, 간단하게 스크립팅을 할 수 있는 루아를 사용하기로 한 것이다!

궁극적으로, Z는 간단하게 헤더에 대한 정보를 반환하는 서버를 배치하고 이 서버에 통신해 헤더를 세팅하는 루아 필터를 만들고자 한다.
서버 구현 내용은 I-헤더 반환 서버 제작에 담는다.

어떤 식으로 동작하는 서버를 만들었는지만 보자면..

keti debug -- curl "$HOST/set/group?value=gold,silver,bronze&weight=10,25,65"
keti debug -- curl "$HOST/get/group"
keti debug -- curl $HOST/header -v | grep group

image.png
set 패스로 key를 명시한 후 쿼리스트링으로 가능한 값들을 전달한다.
이때 전체 합이 100이 되도록 가중치를 같이 전달해주면 된다.
image.png
그 이후에 이 서버를 사용할 때는 header 엔드포인트로 요청을 날리면 된다.
응답 헤더에 설정된 키에 대해 값이 확률적으로 부착되어 반환될 것이다.
여러 개의 키를 등록해도 각각의 키를 전부 반영해준다.

이제 일반 서비스를 배치하고, 해당 서비스의 엔보이에 루아 스크립트를 넣어 이 서버로 요청이 가게만 만들면 된다.
image.png
책 예제의 서비스는 간단한 httpbin 서버이다.
이에 대한 루아 스크립트를 적용하는 엔보이 필터는 이렇게 작성했다.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: httpbin-lua-extension
  namespace: istioinaction
spec:
  workloadSelector:
    labels:
      app: httpbin
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: SIDECAR_INBOUND
      listener:
        portNumber: 80
        filterChain:
          filter:
            name: "envoy.filters.network.http_connection_manager"
            subFilter:
              name: "envoy.filters.http.router"
    patch:
      operation: INSERT_BEFORE
      value: 
       name: envoy.lua
       typed_config:
          "@type": "type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua"
          inlineCode: |
            function envoy_on_request(request_handle)
              local headers, test_bucket = request_handle:httpCall(
              "headerer",
              {
                [":method"] = "GET",
                [":path"] = "/header",
                [":scheme"] = "http",
                [":authority"] = "headerer.istioinaction.svc.cluster.local",
                ["accept"] = "*/*"
              }, "", 5000) 
              for key, value in pairs(headers) do
                if not string.match(key, "^:") then
                request_handle:headers():add(key, value)
                end
              end
            end          
            function envoy_on_response(response_handle)
              response_handle:headers():add("istioinaction", "it works!")
            end
  - applyTo: CLUSTER
    match:
      context: SIDECAR_OUTBOUND
    patch:
      operation: ADD
      value: # cluster specification
        name: headerer
        type: STRICT_DNS
        connect_timeout: 0.5s
        lb_policy: ROUND_ROBIN
        load_assignment:
          cluster_name: headerer
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    protocol: TCP
                    address: headerer.istioinaction.svc.cluster.local
                    port_value: 80

책 예제에서 아주 조금의 수정만 가했다.
httpCall을 통해 헤더러 서비스에 요청을 날리고, 돌아온 헤더를 전부 원본 헤더로 추가시켜버리는 동작이다.
위에서 미리 세팅하기로, group이란 헤더에 대해서 gold, silver, bronze란 값이 붙을 수 있도록 세팅했기 때문에 정상적으로 동작한다면 실제 httpbin 서버에 요청이 들어갈 때의 헤더에는 해당 값이 들어가야만 한다.
아울러 돌아오는 응답 헤더에는 엔보이 필터가 제대로 적용됐는지 체크하기 위해 단순한 커스텀 헤더를 추가했다.

이제 정말 테스트해보자.

keti debug -- curl http://httpbin.istioinaction:8000/headers -v

httpbin 서비스를 이용한 것은 요청 헤더를 응답 바디로 반환하는 간편한 엔드포인트가 있기 때문이다!
image.png
먼저 응답 헤더를 보면 istioinaction: it works!가 붙었고, 이를 통해 루아 스크립트가 성공적으로 동작하고 있다는 것을 짐작할 수 있다.
image.png
또한 응답 바디에는 요청할 때 들어간 헤더들이 나오는데, 여기에 Group 이 있음을 확인할 수 있다!

그럼 Z가 설정한 비율에 맞게 헤더 값이 반영되는지도 확인해본다.

for i in {1..100};
do
  keti debug -- curl http://httpbin.istioinaction:8000/headers | yq '.headers.Group'
  sleep 0.3
done | sort | uniq -c

image.png
브실골을 65,25,10 비율로 세팅했는데 얼추 값이 잘 나오는 것을 확인할 수 있다.

그룹 가중치와 정보를 변경해본다.

keti debug -- curl "headerer/set/group?value=a,b&weight=10,90"
for i in {1..100};
do
  keti debug -- curl http://httpbin.istioinaction:8000/headers | yq '.headers.Group'
  sleep 0.3
done | sort | uniq -c

image.png
이제 Z는 원하는 대로 A/B 테스트의 비율을 조절할 수 있는 능력을 얻었다!
image.png
대충 짰다보니 혹시 빠르게 하면 문제가 생길까 했는데, 그래도 이 정도 요청은 문제 없이 수행하나 보다.

결론

위에 루아를 쓰는 상황, 사실 정말 어거지로 상정해서 만든 상황이란 것을 쉽게 알 수 있을 것이다.
문서에도 나와있지만 엔보이 필터는 정말 도무지 이스티오의 기능만으로는 해낼 수 없는 기능이 있다고 했을 때 도입하는 최후의 수단임을 반드시 명시해야 한다.
개요에서 밝혔듯이 괜히 잘못 적용했다가 메시 망가지면 책임은 온전히 사용자의 몫이다..
엔보이 필터를 쓰는 수밖에 없다고 느껴진다면, 혹시 문제 해결의 시각을 협소하게 가져가고 있는 것은 아닐까 고민해봐야 한다고 생각한다.
웬만한 모든 상황에서 엔보이 필터를 쓰지 않아도 상황을 해결할 수 있도록 많은 기능과 대안이 존재한다고 생각한다.

하위 문서

이름 is-folder index noteType created
E-이스티오에서 엔보이 기능 확장하기 false 1 topic/explain 2025-06-01
I-헤더 반환 서버 제작 false 2 topic/idea 2025-06-08
E-이스티오 메시 스케일링 false 3 topic/explain 2025-06-08
E-istio-csr 사용 실습 false 4 topic/explain 2025-06-09
엔보이에 와즘 플러그인 적용해보기 false 5 topic 2025-06-09
이스티오 스케일링 false 6 topic 2025-05-18

관련 문서

지식 문서, EXPLAIN

이름0is-folder생성 일자

Dataview: No results to show for table query.

기타 문서

Z0-연관 knowledge, Z1-트러블슈팅 Z2-디자인,설계, Z3-임시, Z5-프로젝트,아카이브, Z8,9-미분류,미완
이름0코드타입생성 일자

Dataview: No results to show for table query.

참고


  1. https://www.envoyproxy.io/docs/envoy/latest/configuration/listeners/listeners ↩︎

  2. https://www.envoyproxy.io/docs/envoy/v1.18.2/configuration/http/http_filters/tap_filter#config-http-filters-tap-admin-handler ↩︎

  3. https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/rate_limit_filter ↩︎

  4. https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/other_features/other_features ↩︎

  5. https://github.com/envoyproxy/ratelimit ↩︎

  6. https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter#lua ↩︎

  7. https://github.com/envoyproxy/examples/blob/main/lua/envoy.yaml ↩︎